Skip to content

fix(cua-driver/linux): retry root-only AT-SPI tree on cold Qt6 launch (#1927) - #1998

Merged
f-trycua merged 1 commit into
mainfrom
fix/linux-atspi-cold-launch-retry-1927
Jun 24, 2026
Merged

fix(cua-driver/linux): retry root-only AT-SPI tree on cold Qt6 launch (#1927)#1998
f-trycua merged 1 commit into
mainfrom
fix/linux-atspi-cold-launch-retry-1927

Conversation

@f-trycua

@f-trycua f-trycua commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Problem

On Linux, get_window_state against a Qt6 app launched as the first / only accessibility client sometimes returns just the root window (element_count=1, empty tree). The full tree (e.g. 155 elements) appears only once org.a11y.atspi.Registry is already active. Qt6 has the AT-SPI bridge built in, but registration is timing-sensitive on a cold launch. Fixes #1927.

Root cause

atspi::walk_tree_bounded returned as soon as native::walk_tree produced non-empty markdown — and a root-only tree (the window node with no children) is non-empty, so the degenerate cold-start result was accepted and returned.

Fix

Retry the native walk up to 4 times with a 150 ms backoff while the tree is root-only (nodes.len() <= 1), giving the registry time to finish enumerating the app. Any tree with real content is accepted on the first attempt; the root-only result is still accepted on the final attempt rather than discarded (and the existing X11-properties fallback remains for the truly-empty case). Latency is added only on the cold-start path — a populated window returns immediately.

This implements the issue's "retry/wait briefly for the tree to populate when it comes back with only the root" direction. (A more proactive option — having the daemon pre-activate the registry / toolkit-accessibility at startup — is left as a possible follow-up.)

Verification

  • Build-verified on Linux: cargo build -p platform-linux recompiled green at commit b37d7cc.
  • The cold-launch race is timing-dependent (needs a freshly-launched Qt6 app as the first AT-SPI client); the change is a bounded retry around the existing walk and is safe for the populated case.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability of accessibility tree detection on Linux by implementing retry logic for initial service queries, ensuring more complete initialization during application startup.

…#1927)

On Linux, get_window_state against a Qt6 app launched as the first/only
accessibility client can return just the root window (element_count=1, empty
tree) — the Qt6 AT-SPI bridge registers lazily and org.a11y.atspi.Registry
hasn't finished enumerating the app when the first walk runs. The full tree
appears only once the registry is already active.

walk_tree_bounded returned early as soon as native::walk_tree produced
non-empty markdown, and a root-only tree IS non-empty, so the degenerate
result was returned. Retry the native walk up to 4 times with a 150ms backoff
while the tree is root-only (nodes.len() <= 1), accepting any real tree
immediately and the root-only result on the final attempt. Adds latency only on
the cold-start path; a populated window returns on the first attempt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KMXCW4M5uK1HRGjjH4wueZ
@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Ignored Ignored Jun 23, 2026 9:59am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

walk_tree_bounded in the Linux AT-SPI driver gains a retry loop (up to 4 attempts with sleep between each) for its native path. It now detects the cold-launch degenerate case where the returned tree has only a root node (nodes.len() <= 1) and continues retrying, accepting the root-only result only on the final attempt.

Changes

AT-SPI Cold-Launch Retry

Layer / File(s) Summary
Native AT-SPI walk retry loop
libs/cua-driver/rust/crates/platform-linux/src/atspi/mod.rs
Replaces the single native attempt with a MAX_ATTEMPTS = 4 loop with inter-attempt backoff. Root-only trees (nodes.len() <= 1) are skipped as degenerate on non-final attempts; the last attempt accepts any non-empty result before the X11 fallback is reached.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Possibly related PRs

  • trycua/cua#1961: Introduces the bounded native AT-SPI walking API and structure that this PR's retry logic is layered on top of, both modifying walk_tree_bounded in the same file.

Poem

🐇 The tree came back with just a root,
A cold Qt6 in a lonely boot.
So I tried once, twice, three times more —
Until the full accessibility store
Bloomed wide and open, node by node.
Patience pays on Linux's road! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and specifically describes the main change: retrying root-only AT-SPI tree on cold Qt6 launch, matching the core fix implemented.
Linked Issues check ✅ Passed The PR implements the second approach from issue #1927: a bounded retry with backoff for root-only AT-SPI trees, directly addressing the cold Qt6 launch problem.
Out of Scope Changes check ✅ Passed All changes are scoped to the AT-SPI tree-walking retry mechanism in the platform-linux crate, directly addressing the linked issue with no unrelated modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/linux-atspi-cold-launch-retry-1927

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@libs/cua-driver/rust/crates/platform-linux/src/atspi/mod.rs`:
- Around line 67-79: The retry heuristic in the walk_tree_bounded loop treats
nodes.len() <= 1 as a cold-start symptom unconditionally, causing unnecessary
sleep delays even when callers intentionally request tiny traversals via
max_elements=1 or max_depth=1. Modify the condition on line 73 that checks
nodes.len() > 1 to also account for the max_elements and max_depth parameters,
so that single-node results are accepted immediately when they match the
caller's intentional constraints, rather than forcing the retry loop to
completion. Only treat minimal results as cold-start symptoms when they're
genuinely unexpected given the traversal caps.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 83c11923-106e-4c48-a4f9-52ee3a3ee4a1

📥 Commits

Reviewing files that changed from the base of the PR and between c898d7b and b37d7cc.

📒 Files selected for processing (1)
  • libs/cua-driver/rust/crates/platform-linux/src/atspi/mod.rs

Comment on lines +67 to +79
for attempt in 0..MAX_ATTEMPTS {
if let Ok(Some((raw_md, nodes))) = native::walk_tree_bounded(pid, max_elements, max_depth) {
// `nodes.len() <= 1` == only the root window resolved: the
// cold-registry symptom. Accept any real tree immediately; only
// keep waiting on the degenerate case, and accept it anyway on the
// final attempt rather than discarding a (minimal) valid result.
if !raw_md.is_empty() && (nodes.len() > 1 || attempt == MAX_ATTEMPTS - 1) {
let md = if let Some(q) = query { filter_tree(&raw_md, q) } else { raw_md };
return AtspiTreeResult { tree_markdown: md, nodes };
}
}
if attempt < MAX_ATTEMPTS - 1 {
std::thread::sleep(std::time::Duration::from_millis(150));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Retry heuristic should be cap-aware to avoid forced backoff on intentionally tiny trees.

Line 73 treats nodes.len() <= 1 as a cold-start symptom unconditionally. But callers can intentionally request tiny traversals (max_elements=1 / max_depth=1), where a single-node result is expected; this path then always incurs 3 sleeps (~450ms) before returning.

Proposed fix
 pub fn walk_tree_bounded(
     pid: u32,
     xid: u64,
     query: Option<&str>,
     max_elements: Option<usize>,
     max_depth: Option<usize>,
 ) -> AtspiTreeResult {
     const MAX_ATTEMPTS: usize = 4;
+    const BACKOFF_MS: u64 = 150;
+    let retry_root_only = max_elements.map_or(true, |m| m > 1)
+        && max_depth.map_or(true, |d| d > 1);
+
     for attempt in 0..MAX_ATTEMPTS {
         if let Ok(Some((raw_md, nodes))) = native::walk_tree_bounded(pid, max_elements, max_depth) {
-            if !raw_md.is_empty() && (nodes.len() > 1 || attempt == MAX_ATTEMPTS - 1) {
+            let root_only = nodes.len() <= 1;
+            let should_retry = retry_root_only && root_only && attempt < MAX_ATTEMPTS - 1;
+            if !raw_md.is_empty() && !should_retry {
                 let md = if let Some(q) = query { filter_tree(&raw_md, q) } else { raw_md };
                 return AtspiTreeResult { tree_markdown: md, nodes };
             }
         }
         if attempt < MAX_ATTEMPTS - 1 {
-            std::thread::sleep(std::time::Duration::from_millis(150));
+            std::thread::sleep(std::time::Duration::from_millis(BACKOFF_MS));
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver/rust/crates/platform-linux/src/atspi/mod.rs` around lines 67
- 79, The retry heuristic in the walk_tree_bounded loop treats nodes.len() <= 1
as a cold-start symptom unconditionally, causing unnecessary sleep delays even
when callers intentionally request tiny traversals via max_elements=1 or
max_depth=1. Modify the condition on line 73 that checks nodes.len() > 1 to also
account for the max_elements and max_depth parameters, so that single-node
results are accepted immediately when they match the caller's intentional
constraints, rather than forcing the retry loop to completion. Only treat
minimal results as cold-start symptoms when they're genuinely unexpected given
the traversal caps.

@github-actions

Copy link
Copy Markdown
Contributor

Linux visual regression artifacts

Matrix jobs now run independently. Download visual artifacts from this workflow run.
Each background-GUI job uploads a .gif of the interaction plus two annotated PNGs (<app>.png raw, <app>-atspi.png with AT-SPI element boxes); the cua-driver-linux-som-overlays artifact adds <app>-som.png cua Set-of-Marks overlays:

  • cua-driver-linux-cursor-click-gif
  • cua-driver-linux-background-terminal-gif
  • cua-driver-linux-parallel-drag-xserver
  • cua-driver-linux-background-gui-chromium
  • cua-driver-linux-background-gui-tk
  • cua-driver-linux-background-gui-gtk3-gedit
  • cua-driver-linux-background-gui-gtk3-mousepad
  • cua-driver-linux-background-gui-gtk3-scite
  • cua-driver-linux-background-gui-gtk4-characters
  • cua-driver-linux-background-gui-qt5-manuskript
  • cua-driver-linux-background-gui-qt5-klog
  • cua-driver-linux-background-gui-qt5-openambit
  • cua-driver-linux-background-gui-qt6-kate
  • cua-driver-linux-background-gui-qt6-kcalc
  • cua-driver-linux-background-gui-qt6-okular
  • cua-driver-linux-background-gui-qt6-qownnotes
  • cua-driver-linux-background-gui-electron-zettlr
  • cua-driver-linux-background-gui-electron-joplin
  • cua-driver-linux-background-gui-electron-logseq
  • cua-driver-linux-som-overlays

Open workflow run and download artifacts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cua-driver-rs (Linux): get_window_state can return an empty tree (element_count=1) on a cold Qt6 launch

1 participant